Skip to content
created by Aha00aAha00a at 2026-08-09
last modified by Aha00aAha00a at 2026-09-10
revision: 8

Dev ApiResponse

컨트롤러가 JSON 응답과 권한 거절 응답을 만드는 방식을 공용 트레이트로 모았다.

1. 문제

같은 사실이 여러 파일에 흩어져 적혀 있었다.

중복된 것

사본 수

위치

def Ok(json: io.circe.Json)

4

Api, ApiV1, ApiCrawler, Wiki

isAdmin

4

Api, Admin, ApiCrawler, Wiki

isSiteAdmin

2

Api, Admin

Forbidden("Access denied.")

36

Api 28, Admin 5, ApiCrawler 3

에러 봉투 {"error": ...}

66

Api 43, ApiV1 23

site not found: $seq 404

13

Api

권한 확인 → site 조회 → 404 사다리

12

Api

에러 봉투가 특히 나빴다. 같은 {"error": "..."} 를 두 관용구로 적고 있었다. Json.obj("error" -> Json.fromString(msg))Map("error" -> msg).asJson 은 결과 바이트가 같지만 표기가 달라서, 둘을 맞춰 둘 장치가 없다. 봉투를 바꿔야 할 때 한쪽만 고쳐도 컴파일이 통과한다.

2. 공용 코드

2.1. JsonResults

app/controllers/JsonResults.scala. JSON 응답을 만드는 컨트롤러가 상속한다.

  • Ok(json: Json) — circe Jsonapplication/json 으로 내보낸다.
  • JsonResult(status: Status, json: Json)Ok 이외의 상태 코드용.
  • JsonError(status: Status, message: String) — 에러 봉투를 만드는 유일한 자리.

2.2. AdminAuth

app/controllers/AdminAuth.scala. 관리자 권한 판정과 거절 응답을 함께 둔다. 둘은 함께 바뀌기 때문이다.

  • isAdmin / isSiteAdmin(siteSeq)AdminLogic 위임.
  • AccessDenied — 거절 응답.

Database 를 추상 멤버가 아니라 implicit 파라미터로 받는다. 컨트롤러의 databaseval 이 아닌 implicit 생성자 파라미터라서 추상 멤버를 구현하지 못한다. 구현하게 하려면 클래스마다 val 을 붙여 공개 접근자를 늘려야 하는데, 그 대가로 얻는 것이 없다.

AccessDeniedval 이 아니라 def 다. 트레이트의 val 은 초기화 순서 경쟁에 걸려도 컴파일은 통과하고 생성 시점 NPE 로만 드러난다. Result 생성 비용은 그 위험을 질 만큼이 아니다.

2.3. withSiteAdmin / withAdminSite

ApiAdminSite 안의 private 헬퍼다(2026-08-10 컨트롤러 분리 때 Api 에서 옮겨 갔다). "권한을 확인하고, site 를 읽고, 없으면 404" 순서를 한 곳에 둔다.

private def withSiteAdmin(seq: Long)(block: Site => Result)(implicit request: RequestHeader): Result =
  if (!isSiteAdmin(seq)) AccessDenied
  else SiteLogic.get(seq)(database).fold(siteNotFound(seq))(block)

ApiAdminSite 밖으로 올리지 않았다. 호출부가 전부 ApiAdminSite 안에 있기 때문이다. 필요한 것보다 멀리 올리는 것은 그 자체로 결합이다.

3. 통일하지 않고 남겼던 것 — 2026-08-29 에 통일했다

1차 정리는 응답 바이트를 바꾸지 않았고, 아래 불일치를 의도적으로 남겼다. 클라이언트가 무엇을 파싱하는지 확인하지 않은 채 봉투를 바꾸면 조용히 깨지기 때문이다. 이번에 그 확인을 했고, 아래 셋을 통일했다. 남은 자리 하나는 이 절 끝에 있다.

  • ApiCrawler{"message": ...} 일곱 곳 → JsonError. 소비자는 링크 프리뷰(view.scala.htmlshowPreview) 하나이고, 오류에서 본문을 읽지 않는다 — console.log 후 프리뷰를 지울 뿐이다.
  • Api.pageRevision 의 404 — **체크박스의 전제가 먼저 사라져 있었다.** 세 번째 봉투({"success": false, ...})를 쓰던 404 분기는 그 사이(fbc49b09) 없어졌고, 없는 페이지는 revision 0 으로 200 을 답한다. 남아 있던 것은 {"status": "ok", "pageName", "revision"} 라는 Play-JSON 성공 봉투였고, 유일한 소비자(AhaWiki.Kanban.jsfetchLatestRevision)가 .revision 만 읽으므로 {"revision": N} 으로 줄였다.
  • AccessDeniedJsonError(Forbidden, "Access denied."). 관리자 UI 의 읽기 쪽 fetchJson!response.ok 면 본문을 읽지 않고 HTTP <status> 로 던진다. 쓰기 쪽 send 와 계정 화면은 2026-08-31 부터 {"error"}error 를 읽어 메시지로 쓴다 — 다른 모양의 거절 본문을 파싱하는 곳은 없다. Admin 의 HTML 라우트도 같은 JSON 으로 답하게 됐는데, 그것이 결정이다: 관리자 UI 는 비관리자에게 그 경로를 보여주지 않고, 거절 모양 하나가 둘보다 낫다.

확인하다 **네 번째 봉투**도 나왔다. Api.renderAhaMark{"status": "ok", "html"} / {"status": "error", "message"} 를 쓰고 있었다. 소비자(AhaWiki.Kanban.js 의 renderComment)가 성공에서 .html 만, 실패에서 payload.message || payload.error 를 읽어 **양쪽 봉투를 다 받아주게** 짜여 있어서 함께 통일했다 — 성공은 {"html": ...}, 실패는 JsonError. 이것으로 Api.scala 에서 Play-JSON import 가 사라졌다.

서버와 클라이언트가 같은 릴리스로 나가므로 이 확인 방식이 성립한다 — 외부에 문서화된 소비자가 있는 /api/v1 은 이번 범위에 없다.

3.1. 1차 정리에서 놓친 것

.toString()).as(JSON) 로 다시 훑어 손으로 봉투를 만드는 자리를 더 찾았다. 패턴으로 치환할 때는 치환 대상 목록 자체를 의심해야 한다는 뜻이다.

그래도 하나는 남아 있다. Api.pagePreview 의 404 는 {"success": false, "message"} 를, 403 은 같은 모양을 .toString()).as(JSON) 로 만든다. 소비자는 view.scala.htmlshowPreview 하나이고 오류 본문을 읽지 않으므로 통일해도 깨질 것은 없다 — 통일하려면 여기가 남은 자리다.

  • Api.adminGenerateSignedReadUrl — 트레이트에 Ok(json) 이 있는데 같은 식을 손으로 적고 있었다.
  • ApiV1 의 revision 충돌 응답 3벌 — revisionConflict 로 뽑았다. latestRevision 을 함께 실어야 해서 JsonError 로는 표현되지 않고 JsonResult 를 쓴다.
  • Api.pageRevision 의 404 — 봉투 모양은 호출부를 모르므로 그대로 두고 JsonResult 만 태웠다.

4. 목록 봉투

페이지네이션 목록은 {"array": [...], "page": N, "pageSize": N, "count": N} 하나로 답한다. JsonResults.pagedJson 이 만드는 유일한 자리다.

네 endpoint 가 이걸 손으로 만들고 있었고 **이미 갈라져 있었다** — 셋은 page·pageSize 를 보내고 ApiCrawlerarray·count 만 보냈다. 관리자 UI 가 둘 다 받아주게 짜여 있어서 아무도 몰랐다. 네 번째 endpoint 로 페이징을 시작하는 클라이언트가 있었다면 필요한 필드가 없다는 걸 그때 발견했을 것이다.

푸는 쪽도 하나다. app/assets/js/admin/api.jsunwrapPagedpagedParams 가 각각 응답 해체와 질의 파라미터 조립을 맡는다. hook 다섯 곳이 각자 풀고 있었다.

5. 에러 봉투는 compact 가 아니다

circe 의 Json.toString 은 compact 가 아니라 spaces2 로 찍는다. 실제 바이트는 아래와 같다.

{
  "error" : "site not found: 999"
}

리팩터링 전 두 관용구가 모두 Json.toString 을 거쳤으므로 이 형태였고, JsonError 도 같다. compact 라고 넘겨짚고 클라이언트에서 문자열을 비교하면 어긋난다.

6. 검증 결과

  • sbt compile 성공
  • sbt test 성공 — 기존 테스트를 하나도 바꾸지 않았다
  • 중복 표기 137곳을 공용 헬퍼 호출로 치환
  • 기존 컨트롤러 5개 순 -80줄, 새 트레이트 47줄을 더하면 순 -33줄

응답 바이트는 일회성 스펙으로 실제 앱을 띄워 라우터를 통과시켜 확인한 뒤, 앱을 소켓까지 띄우고 curl 로 다시 확인했다.

요청

응답

GET /api/Admin/Sites

403 application/json {"error" : "Access denied."}

GET /Admin/Sites

403 application/json {"error" : "Access denied."}

GET /api/Admin/CrawlerCache

403 application/json {"error" : "Access denied."}

GET /api/Admin/Site/999/Admins

403 — site 조회보다 권한 확인이 먼저

GET /api/v1/pages

401 application/json {"error" : ...}

GET /api/crawler?q=http://127.0.0.1/

403 application/json {"error" : ...}

GET /api/csrf

200 application/json

GET /w/FrontPage

200 text/html, 실제 페이지 렌더링

모르는 site 를 물어도 외부인에게는 404 가 아니라 403 이 간다. 권한 확인이 먼저라, 응답이 site 의 존재 여부를 알려주지 않는다. siteSeq 를 파라미터로 받는 favicon·테마 endpoint 다섯도 resolveAdminTargetSiteWithAuth 에서 같은 순서다. 2026-09-10 까지는 그 다섯만 site 를 먼저 읽어서, 모르는 seq 에는 누구에게나 400 을, 있는 seq 에는 403 을 답했다 — 밖에서 어느 사이트 번호가 있는지 셀 수 있었다. ApiSiteAdminSpec 이 이제 둘 다 403 인 것을 지킨다.

withAdminSite 로 접은 endpoint 들의 상태 코드는 기존 ApiSiteAdminSpec 이 지키고, **봉투의 본문과 Content-TypeApiErrorEnvelopeSpec 이 지킨다** — 위 표 중 /api/Admin/Sites, /Admin/Sites, /api/crawler 의 거절 봉투가 그 스펙의 단언이다. 나머지 줄은 curl 로만 확인했다. 1차 정리 때는 본문을 보는 검사가 없어서 일회성 curl 로 쟀는데, 검사 없는 모양이 셋 반까지 불어난 경위가 그것이다.

7. 로컬 실행

로컬 실행은 ~/.config/ahawiki/application.local.dev.conf 로 한다. 이 저장소에 없는 로컬 파일이다. 띄우기 전에 알아 둘 것이 둘 있다.

  • 클래스패스의 캐시 구현은 play-redis 뿐이다. caffeine 도 ehcache 도 없어서 Redis 없이는 서비스되지 않는다. Redis 모듈을 아예 켜지 않은 설정으로 띄우면 Guice 가 SyncCacheApi 바인딩을 찾지 못해 부팅 자체가 실패하고, 모듈은 켰는데 Redis 에 닿지 못하면 부팅은 되지만 매 요청이 500 이 된다.
  • base.confplay.evolutions.db.default.autoApply = true 가 여기에도 적용된다. 뭔가 확인하려고 띄우는 것이라면 꺼서 공유 DB 스키마를 건드리지 않게 한다.
sbt -Dconfig.file="$HOME/.config/ahawiki/application.local.dev.conf" \
    -Dplay.evolutions.db.default.autoApply=false \
    -Dhttp.port=9123 run

설정된 Redis 에 닿지 못하면 그 부분만 갈아끼우면 된다. 나머지는 설정이 가리키는 곳을 그대로 쓴다.

docker run -d --name ahawiki-local-redis -p 16379:6379 redis:7-alpine
sbt -Dconfig.file="$HOME/.config/ahawiki/application.local.dev.conf" \
    -Dplay.evolutions.db.default.autoApply=false \
    -Dplay.cache.redis.host=localhost -Dplay.cache.redis.port=16379 \
    -Dhttp.port=9123 run

7.1. Host 헤더가 site 를 고른다

SiteLogic.get(host) 가 요청 host 로 site 를 찾고, 못 찾으면 Site.notFound 로 떨어진다. 127.0.0.1:9123 으로 직접 부르면 페이지 목록이 빈 배열로 나오는데, 고장이 아니라 그 host 에 걸린 site 가 없다는 뜻이다. 실제 데이터를 보려면 실제 site 의 host 를 실어야 한다.

curl -H "Host: your.wiki.host" http://127.0.0.1:9123/api/pageNames

8. See Also

8.2. Similar Pages

Similar pages by cosine similarity. Words after page name are term frequency.

  • 56.85% Dev ApiControllers api(42:24), json(42:3), site(36:9), admin(33:12), seq(9:3), with(7:4), 같은(7:3), wiki(5:5), endpoint(4:6), crawler(8:1)
  • 53.89% Dev SiteAdmin site(36:27), admin(33:25), api(42:5), seq(9:6), access(9:1), spec(3:7), with(7:1), 같은(7:1), logic(3:4), dev(5:1)
  • 52.90% Dev AdminUIRoleMenu api(42:8), site(36:14), admin(33:14), seq(9:5), get(10:1), 403(9:2), access(9:2), host(9:1), crawler(8:1), page(8:1)
  • 43.47% Dev Api api(42:147), admin(33:20), json(42:1), page(8:33), wiki(5:22), get(10:14), v1(5:19), aha(3:19), 403(9:10), 않는다(3:16)
  • 41.95% Api api(42:84), json(42:5), admin(33:3), page(8:27), revision(10:19), error(21:1), name(2:20), v1(5:16), 페이지(2:19), wiki(5:15)
  • 33.00% Dev SisterWiki site(36:51), api(42:1), admin(33:1), page(8:14), wiki(5:12), 같은(7:8), application(10:1), seq(9:2), host(9:1), redis(9:1)
  • 31.32% Dev Cache site(36:35), cache(4:58), api(42:12), admin(33:8), wiki(5:33), aha(3:34), 캐시(1:29), page(8:20), seq(9:17), redis(9:6)

8.3. Adjacent Pages

Control
≤ 32
all
1.0x
1.0x
80
-120
ON
Metrics
Nodes(visible/total)0/0
Links(visible/total)0/0
Avg degree0.00
Depth coverage0
Queue(fetch/graph)0 / 0
Zoom(scale)1.00x
Ctrl/⌘ + Scroll: Zoom
Root 1-hop 2-hop+